Skip to content
created by Aha00aAha00a at 2026-08-21
last modified by Aha00aAha00a at 2026-09-15
revision: 7

Dev Deploying

Dev

deploy.sh 는 로컬에서 빌드해 서버에 새 릴리스로 올린다. 서버에 이미 ssh 가 되고 거기서 sudo -n 이 되는 기계에서 돌린다.

AHAWIKI_DEPLOY_HOST=<ssh host>  AHAWIKI_HEALTH_HOST=<a site's domain>  bash deploy.sh

어디에 배포하는지는 전부 환경변수에서 온다. 이 저장소는 공개라서 자기 기계 이름을 하나도 적지 않는다.

그것이 경계의 전부이고, 한 문장으로 적을 가치가 있다: 배포가 어떻게 도는지는 이 저장소가 갖고, 그것이 어느 기계에서 일어나는지는 운영을 담는 쪽이 갖는다. 단계·순서·헬스체크·태그 형식·롤백은 전부 여기, 그 모양을 결정하는 코드 옆에 있다. 호스트명·계정·앞단 리버스 프록시는 저쪽에 있고, 이것을 부르는 데는 값들과 한 줄이면 된다:

AHAWIKI_DEPLOY_HOST=… AHAWIKI_HEALTH_HOST=… AHAWIKI_VERIFY_URLS="… …" bash /path/to/AhaWiki/deploy.sh

절차의 사본을 저쪽에도 두면 같은 사실이 두 곳에 있게 되고, 반드시 갈라진다 — 이것이 대체한 사본은 아무도 읽지 않는 디렉터리에 쓸 만큼 낡아 있었다.

AHAWIKI_DEPLOY_HOST

필수 — ssh 호스트 또는 alias

AHAWIKI_HEALTH_HOST

필수 — 위키 중 하나가 답하는 도메인. AHAWIKI_VERIFY_URLS 를 안 주면 https://<이 값>/ 이 확인 대상이 된다. 이름과 달리 5단계의 /hc 폴링은 안 쓴다 — /hc 는 Host 헤더가 필요 없다. 그 뒤 프록시 기다림에는 첫 확인 URL 로 쓰인다

AHAWIKI_REMOTE_ROOT

/opt/ahawiki

AHAWIKI_SERVICE_USER

ahawiki

AHAWIKI_PORTS

10001 10000 — 재시작 순서

AHAWIKI_VERIFY_URLS

끝나고 확인할 공개 URL 들, 공백 구분. 첫 URL 은 5단계에서도 쓴다 — 인스턴스 하나를 재시작한 뒤 프록시가 다시 답할 때까지 이것을 기다린다

AHAWIKI_KEEP_RELEASES

3

SKIP_TAG

1 이면 배포 태그 생략

1. 무엇을 하는가

릴리스는 releases/ 아래 나란히 놓이고 current 가 그중 하나를 가리키는 심볼릭 링크라서, 배포는 링크를 옮기는 것이고 롤백은 도로 옮기는 것이다:

H=<host>; S=<a site's domain>
ssh $H "sudo -n -u ahawiki ln -sfn /opt/ahawiki/releases/<previous> /opt/ahawiki/current"
for p in 10001 10000; do
  ssh $H "sudo -n systemctl restart ahawiki@$p"
  until [ "$(ssh $H "curl -s -o /dev/null -w '%{http_code}' --max-time 5 http://127.0.0.1:$p/hc")" = 200 ]; do sleep 3; done
  until [ "$(curl -sL -o /dev/null -w '%{http_code}' --max-time 15 https://$S/)" = 200 ]; do sleep 3; done
done

"하나 재시작, sleep 20, 다른 하나 재시작" 보다 길고, 두 until 줄이 그 이유다. 롤백은 이미 무언가 잘못됐을 때 도는데, 그때는 20초가 모자랐다는 것을 발견하기에 최악의 순간이다 — 인스턴스가 4초 만에 돌아왔는데 20초를 앉아 기다리는 것도 마찬가지다. 두 줄은 deploy.sh 가 기다리는 두 조건 그대로이고(이유는 아래), 둘째 줄은 군더더기가 아니다: loopback 에서 답하는 인스턴스가 곧 프록시가 트래픽을 보내 주는 인스턴스인 것은 아니다. 이 문서의 롤백 절차는 2026-08-16 까지 sleep 20 이라고 적혀 있었다 — 배포 경로가 초 세기를 그만둔 지 한참 뒤까지.

cache/ 와 logs/ 는 shared/ 에 살고 각 릴리스에 링크로 들어간다. 어느 릴리스보다도 오래 살기 때문이다. systemd 인스턴스 둘(ahawiki@<port>)이 리버스 프록시 뒤에 있다. 둘 다 요청을 받는다 — 2026-09-10 에 [[Uptime]] 을 여덟 번 렌더링하니 JVM 시작 시각 둘이 5:3 으로 번갈아 나왔다. /w/ 를 연달아 부르면 봇 차단에 걸리므로 /w/ 밖의 경로로 잰다:

for i in 1 2 3 4 5 6 7 8; do curl -s -X POST -H 'content-type: application/json' -d '{"comment":"[[Uptime]]"}' https://ahawiki.net/api/renderAhaMark/FrontPage; echo; sleep 1; done | sort | uniq -c

그래서 인스턴스마다 따로 있는 메모리 상태는 서로를 모른다 — 캐시는 Dev Cache 의 «인스턴스가 둘이다», 실시간 이벤트는 Dev WebSocket.

AHAWIKI_KEEP_RELEASES 를 넘는 옛 릴리스는 마지막에 지워진다. current 가 가리키는 것은 건너뛴다. 스크립트의 모든 원격 블록은 set -e 로 시작하고, 지우는 블록이 그것이 여전히 그런지 확인해야 하는 이유다. 없으면 releases/ 로의 cd 가 실패해도 아무것도 멈추지 않는다: 루프는 로그인 사용자의 홈 디렉터리를 목록하며 계속 돌고, 거기서는 "이것이 current 인가" 검사에 아무것도 걸리지 않아, 눈에 띄는 가장 오래된 항목들에 sudo rm -rf 를 돌린다. 실패가 아무도 종료코드를 읽지 않는 명령 안에 있어서, 배포는 어느 쪽이든 성공을 보고한다.

2. 왜 이런 모양인가

각각이 한 번씩 배포를 부순 값이다.

  • 한 대씩, 그리고 프록시가 동의해야 한다. 둘을 같이 재시작하면 살아 있는 upstream 풀이 비어 독자가 502 를 본다. 다음 대를 내리기 전에 앞 대가 답해야 하고, 끝내 답하지 않으면 스크립트는 다른 대가 아직 서비스하는 채로 멈춘다. 자기 답만으로는 부족하다. 죽은 upstream 을 떨어뜨리는 프록시는 정해진 간격 동안 그것을 빼놓으므로, 인스턴스가 loopback 에서는 서비스 중인데 프록시는 여전히 아무것도 보내 주지 않을 수 있다 — 그 틈에 다른 대를 내리면 로테이션에 아무것도 남지 않는다. 그 외에는 멀쩡했던 배포에서 1초쯤의 502 로 값을 치렀다. 그래서 인스턴스 사이에서 스크립트는 그 간격에 맞춘 수를 자는 대신 프록시를 통한 요청이 돌아오기를 기다린다 — 수를 자면 그 간격이 두 곳에 적히게 된다.
  • 헬스체크는 /hc 를 찌른다. 위키 페이지가 아니다. /hc 는 SELECT 1 로 DB 를, 그리고 디스크 여유를 확인하고 OK 를 답한다 — 재시작이 깨뜨릴 수 있는 것들이다. 사이트 매칭을 타지 않으니 Host 헤더도 필요 없다.

그리고 /w/ 밖에 있어야 한다. IpRateLimiter 는 /w/ 아래를 페이지 조회로, 나머지를 사람 신호로 세고, 짧은 창 안에서 페이지만 여러 번 요청한 주소를 차단한다(임계값과 그 이름은 Dev BotDetection). deploy.sh 의 폴링 간격(sleep 3)으로 /w/FrontPage 만 부르는 것이 정확히 그 정의다. 2026-09-04 에 이 루프가 다섯 번째 폴링에서 자기를 차단했다 — 남은 폴링은 403 과 tarpit 을 받았고, 인스턴스가 독자에게 정상 서비스하는 동안 배포는 never became healthy 로 죽었다. 차단은 IpDeny 에 행으로 남고 두 인스턴스가 그 테이블을 공유하므로, 고치지 않았다면 보관 기간 내내 모든 배포가 같은 자리에서 실패했다.

기동이 15초 안에 끝나면 네 번으로 통과한다. 그래서 몇 달간 멀쩡했고, 릴리스가 무거워지자 걸렸다. 폴링 창은 AHAWIKI_HEALTH_TRIES(기본 60 × 3초 = 180초)다. 2026-09-15 에는 두 인스턴스가 같이 뜨며 CPU 를 다투자 차가운 JVM+Play 기동이 첫 대에서 90초(옛 30회 고정)를 넘겨, 멀쩡한 기동을 배포가 실패로 보고하고 한 대를 옛 릴리스에 남긴 채 멈췄다 — 그때는 current 가 이미 새 릴리스를 가리키므로, 남은 한 대를 손으로 재시작해(새 릴리스를 집는다) 마쳤다. 더 느린 장비는 이 값을 올린다. 사이트가 실제로 그려지는지는 5단계의 프록시 기다림과 6단계가 프록시를 통해, 화이트리스트에 든 주소에서 확인한다 — /hc 폴링이 넓게 볼 이유가 없다. 7단계는 태그일 뿐 아무것도 확인하지 않는다.

  • 외부 확인은 리다이렉트를 따라간다. 303 을 답하는 첫 페이지는 그것이 어디로 가는지에 대해 아무 말도 하지 않는다. 뒤의 모든 페이지가 500 인 동안 303 으로 통과한 배포가 있었다.
  • 외부 확인은 재시도한다. 인스턴스가 답한다고 프록시가 알아챈 것이 아니다. 죽은 upstream 을 정해진 간격 동안 떨어뜨리는 프록시는 회복한 뒤에도 남은 간격 동안 계속 떨어뜨리므로, 마지막 재시작이 헬스체크를 통과한 순간에 확인하면 멀쩡한 배포에서 502 를 읽는다 — 실제로 일어났고, 좋은 릴리스의 태그 하나를 값으로 치렀다.
  • 업로드는 rsync 가 아니라 tar over ssh 다. Git Bash 에서 MSYS2 rsync 를 부르면 두 MSYS 런타임을 건너느라 인자가 뭉개져 도착하고, 아무것도 복사하기 전에 죽는다.
  • 태그는 맨 마지막에 쓴다. 검증 뒤에 써서, "이것이 빌드됐다" 가 아니라 "이것이 서버에 닿아 답했다" 를 뜻하게 한다.
  • conf/evolutions 가 바뀐 배포는 백업을 먼저 본다. 스크립트는 이것을 하지 않는다 — 어느 데이터베이스인지가 운영 쪽 사실이라서다. 확인 명령과 멈춰야 할 값은 운영 저장소 ahawiki/README.md 에 있고, 읽기 전용 한 줄이다. 2026-09-02 에 evolution 둘이 이것 없이 나갔다. 안전망은 있었지만 그것을 안 것은 배포 뒤였고, 그날 두 번째 배포는 실제로 실패해 되돌려야 했다(Dev Database).

3. 비밀이 든 설정을 conf/ 밖에

sbt stage 는 conf/ 아래 모든 파일을 릴리스에 복사한다. gitignore 는 거기 관여하지 못한다 — git 이 무엇을 추적하는지를 다스릴 뿐, 빌드가 무엇을 패키징하는지는 아니다 — 그래서 거기 둔 로컬 설정은 배포마다 서버로 따라간다.

가정이 아니다. 개발 DB 비밀번호와 play.http.secret.key 가 릴리스 세 개에, world readable 로, 앱이 열지도 않는 파일에 앉아 있었다: 앱은 릴리스 밖을 가리키는 절대 경로 -Dconfig.file 로 시작된다. 지금 구조가 시작된 뒤로 배포마다 나가고 있었다.

build.sbt 는 이제 git 에게 conf/ 아래 무엇을 추적하는지 물어 그것만 패키징한다. ignore 된 파일은 실리지 않는다 — 모두가 이미 그런 줄 알았던 바로 그것이다. 무엇을 뺐는지도 말한다:

[info] conf/: not packaged (untracked): conf/something.local.conf

파일명 패턴을 먼저 시도했고, git 이 없는 빌드의 폴백으로만 남겼다. 패턴은 누군가 떠올린 모양들을 잡는데, 이 일의 발단이 된 파일은 그중 어느 모양도 아니었다: 호스트명을 따서 지은 이름이었다.

그 폴백은 2026-08-15 에 처음 실제로 돌려 봤다 — git 을 PATH 에서 빼고, 미끼 파일 둘을 심어서: 하나는 *.local.*, 하나는 호스트명을 딴 이름. 설계대로 동작했고 한계도 위에 적은 그대로다: 첫째는 빠졌고 둘째는 실렸다. 그래서 폴백은 경고하고, 이제 패키징하려는 conf/ 최상위 목록도 찍는다. "릴리스를 확인하라" 는 뺀 것의 목록으로는 답할 수 없기 때문이다 — 걱정할 파일은 정의상 패턴이 알아보지 못한 그 파일이다.

[warn] conf/: git unavailable, falling back to name patterns. Check the release for local configs.
[warn] conf/: packaged on the name rule alone: conf/application.conf
[warn] conf/: packaged on the name rule alone: conf/base.conf
...

여섯 줄쯤이고, 낯선 설정 하나는 거기 끼면 눈에 띈다. 하위 디렉터리는 그 목록에서 뺐다 — 기본 페이지와 evolution 전부는 하나를 숨기기에 충분히 길다. git 이 있으면 이것들은 하나도 나타나지 않는다. 보통의 빌드가 전부 그쪽이다.

배포 설정이 conf/ 에 살아야 할 이유는 아무것도 없다 — 서버는 절대 경로로 설정 위치를 받는다 — 그러니 가장 안전한 자리는 여전히 아예 다른 곳이다.

4. 전에 여기 있던 것

2026-08-12 까지 이 저장소에는 다른 배포 장치 둘이 실려 있었고 둘 다 더는 서버와 맞지 않았다: 옛 경로에 빌드를 덮어 복사하는 스크립트 하나, 서버에서 빌드해 pm2 로 띄우는 GitHub Actions 워크플로 하나. 운영은 current 에서 도는 systemd 서비스로 옮겨 간 뒤라, 스크립트는 아무도 읽지 않는 디렉터리에 썼고 워크플로는 돌고 있는 서비스와 포트를 두고 경합했을 것이다. 둘 다, 의존하던 pm2 launch 스크립트·process 파일과 함께 제거했다.

5. See Also

5.2. Similar Pages

Similar pages by cosine similarity. Words after page name are term frequency.

  • 41.52% Dev RunningLocally conf(16:16), ahawiki(24:4), host(10:7), dev(8:9), 않는다(8:7), 있는(4:9), 있다(4:9), 없다(4:5), play(2:7), 로컬(1:8)

5.3. Adjacent Pages

Control
≤ 32
all
1.0x
1.0x
80
-120
ON
Metrics
Nodes(visible/total)0/0
Links(visible/total)0/0
Avg degree0.00
Depth coverage0
Queue(fetch/graph)0 / 0
Zoom(scale)1.00x
Ctrl/⌘ + Scroll: Zoom
Root 1-hop 2-hop+